
上一篇把 embeddedServer 那行的 3 個角色拆完了,Application、module、engine 各管各的事,day 02 的程式碼裡還剩最後一塊沒講到,routing { get("/") { ... } } 這段 DSL,這篇把它拆開,路由怎麼註冊、path 和 method 怎麼對應 handler、這種寫法為什麼能成立
同時,day 01 那時說過,這個系列有一條貫穿的主線,一個 Todo API,從 day 05 加上第 1 個端點開始,跟著整個系列一路長大,GET /todos 這個端點會在這篇開出來,之後接 JSON、加驗證、抽 repository、換資料庫、加認證,全部都疊在它上面
todo-api 加上第 1 個真正的端點,GET /todos 回傳待辦清單routing { } 是什麼、get(...) 在做什麼、handler 裡的 call 是誰這篇要加的是一個新端點,行為很明確,測試先寫下來當規格。在 src/test/kotlin/com/cashwu/todo/TodoRoutesTest.kt 加上
package com.cashwu.todo
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
class TodoRoutesTest {
@Test
fun `todos path responds all todos`() = testApplication {
application {
module()
}
val response = client.get("/todos")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("買牛奶\n繳電費\n寫 day 05 的文章", response.bodyAsText())
}
@Test
fun `todos path with trailing slash responds not found`() = testApplication {
application {
module()
}
val response = client.get("/todos/")
assertEquals(HttpStatusCode.NotFound, response.status)
}
}
第 1 個測行為,打 GET /todos 要拿到 200,body 是換行分隔的 3 筆待辦
第 2 個測邊界,而且是刻意挑的邊界,打的路徑是 /todos/,只比第 1 個多一個尾斜線,期望卻是 404,Ktor 預設把 /todos 和 /todos/ 當成 2 條不同的路徑,我們只註冊了前者,後者沒人接就是 404,這個行為的成因,還有想讓 2 條路徑一視同仁的話要怎麼改,是 day 06 匹配規則的主題,這裡先用測試把預設行為確認下來,之後 day 06 動到它時,這個測試會第一時間告訴我們行為變了
把 src/main/kotlin/com/cashwu/todo/Application.kt 改成
package com.cashwu.todo
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
val todos = mutableListOf("買牛奶", "繳電費", "寫 day 05 的文章")
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
get("/todos") {
call.respondText(todos.joinToString("\n"))
}
}
}
跟 day 02 的版本相比只多了 2 個東西,一個 top-level 的 todos 清單,還有 routing 區塊裡的第 2 條路由,改動很小,正好把注意力放在這幾行到底在做什麼
routing { } 看起來像個語法結構,其實它就是一個普通的函式,它做的事是把 Routing 這個 plugin 裝到 Application 上,再把你給的區塊套用上去,區塊裡註冊的路由全部進到這個 plugin 管理的路由樹裡
plugin 這個詞在系列裡是第 1 次出現,先給一個夠用的理解就好,plugin 是 Ktor 幫 Application 加能力的機制,routing 是其中一個,之後要裝的 ContentNegotiation、Authentication 也都是
get("/todos") { ... } 是在路由樹上註冊一筆對應,HTTP method 是 GET、path 是 /todos 的請求,交給後面那個 lambda 處理,請求進來時,Ktor 拿 method 和 path 去路由樹裡找,找到就執行對應的 handler,找不到就回 404,day 03 那個打 /nothing-here 拿到 404 的測試,走的就是「找不到」這條路
get 之外,post、put、delete 這些 builder 都有,一個 method 一個,之後的篇章會逐一用到,Todo API 的新增、修改、刪除就靠它們
handler 是一個 suspend lambda,所以裡面可以直接呼叫 call.respondText(...) 這類 suspend 函式,不用自己處理協程,跟 day 03 講 testApplication 的 lambda 是同一個道理
call 代表這一次請求與回應的上下文,請求的資訊從它身上讀,回應也透過它寫回去,call.respondText(...) 做的事就是把一段純文字連同 200 狀態碼寫進回應,狀態碼和 content type 都有預設值,要改的話它有參數可以使用,後面的篇章用到再說
把語法糖剝掉一層就清楚了,get("/todos") { ... } 其實是 get("/todos", { ... }),Kotlin 規定最後一個參數是 lambda 時可以移到括號外面,這叫 trailing lambda,而 routing { } 的區塊是 lambda with receiver,區塊裡的 this 是路由樹的節點 (Route),get 正是定義在 Route 上的 extension function,看一眼 import 那行 io.ktor.server.routing.get 就知道它不是什麼關鍵字,只是一個函式,所以整段 DSL 就是普通的函式呼叫加上 2 個語言特性,沒有 annotation processing,也沒有 code generation
這 2 個特性 Relix 系列在 day 10 從零拆解過,那篇把 lambda with receiver 從普通 lambda 一步步推到 routing DSL,想看完整推導的話可以回去讀這篇文章
這版實作有 2 個地方,不是好的做法,是刻意的最簡起點
todos 是放在 top-level 的 mutableList,資料放在記憶體,server 重新啟動就消失,也還沒有任何並行保護,2 個請求同時改它會有問題,day 19 會把它抽成 repository 交給 DI 管,day 20 之後換成真的資料庫,在那之前它就是一個能讓路由有東西可回的最小資料來源joinToString("\n") 拼出來的純文字就夠用了./gradlew test
實測的結果是
> Task :test
ApplicationTest > root path responds hello() PASSED
ApplicationTest > unknown path responds not found() PASSED
EnvironmentTest > Ktor EmbeddedServer class is available() PASSED
TodoRoutesTest > todos path responds all todos() PASSED
TodoRoutesTest > todos path with trailing slash responds not found() PASSED
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
5 個測試通過,2 個是這篇新增的。再用真的 HTTP 確認一次,./gradlew run 跑起 server 之後,另開視窗打
curl http://localhost:8080/todos
買牛奶
繳電費
寫 day 05 的文章
第 1 個端點通了。順手把尾斜線那條也打一次,只印狀態碼
curl -o /dev/null -w "%{http_code}" http://localhost:8080/todos/
404
跟測試講的一樣,/todos/ 是另一條路,沒註冊就是 404
routing { },直接在 module 裡寫 get(...)
初學很容易這樣寫
fun Application.module() {
get("/todos") {
call.respondText(todos.joinToString("\n"))
}
}
這個編譯不會過。前面說了,get 是定義在 Route 上的 extension function,module 裡的 this 是 Application,不是 Route,compiler 找不到能用的 get 就直接出現編譯錯誤,錯在編譯期反而是好事,不會做出一個看起來能跑但路由沒註冊的 server,看到這種錯誤訊息,先檢查是不是少包了一層 routing { }
已經由第 2 個測試示範過了,/todos 和 /todos/ 是 2 條路徑,只註冊一條,另一條就是 404,從瀏覽器或別的工具複製 URL 過來的時候,尾巴多一個斜線很常見,打不到的時候先看一眼路徑尾巴。這個行為能不能改、怎麼改,day 06 講匹配規則時一起處理
Relix 的路由是從一個 Map 起家的。day 07 用 2 層 Map 做路由表,外層 key 是 path、內層 key 是 method,查詢就是 2 層取值,查不到就自己回 notFound(),連 404 這件事都是自己寫的一行程式,那篇也把 Map 路由的限制列得很清楚,沒有 405、/hello 和 /hello/ 是 2 個不同的 key、路徑參數做不到,後面幾篇才用 Router 一項一項補上
DSL 也是後來才長出來的,一開始註冊路由是 app.get("/hello") { ... } 這種散裝寫法,day 10 才用 lambda with receiver 做出 routing { },讓 RoutingBuilder 收集路由再交給 Router,巢狀的 route("/api") { ... } 又是再下一篇的事
回頭看這篇,Ktor 開箱給的就是那個「長完的形狀」,routing { }、method builder、404,全部都在,而且底下是路由樹,不是扁平的 Map,巢狀路由、路徑參數這些 Relix 花好幾篇才補齊的能力,它本來就支援,後面的篇章用到就直接拿,手刻過一次的價值在這裡,你知道 DSL 底下那棵樹在回答什麼問題,也知道「查不到回 404」不是理所當然,是有人寫的
有一個小地方兩邊走向不同,Relix 的 Router 後來把 trailing slash 放進匹配規則裡處理掉了,Ktor 的預設反而是把它們當 2 條路徑,要一視同仁得自己開,預設值沒有絕對的對錯,但這正好說明為什麼要用測試把預設行為固定住,框架的預設跟你的直覺不一定同一邊
Todo API 的主線從這篇開始了,第 1 個端點 GET /todos 用 2 個測試確認了行為和邊界,5 個測試通過,routing DSL 也拆完了,routing { } 是把 Routing plugin 裝上 Application 的普通函式,get(...) 在路由樹上註冊 method 加 path 對應的 handler,handler 是 suspend lambda,call 是這一次請求與回應的上下文,整段語法靠 trailing lambda 和 lambda with receiver 成立,資料還在記憶體、回應還是純文字,這些都是刻意的
下一篇講路由的匹配規則,path 參數 {id} 和 query 參數怎麼拿、怎麼驗,還有這篇留下的尾斜線問題,/todos 和 /todos/ 為什麼預設是 2 條路徑、想讓它們走同一條要怎麼做,Todo API 也會加上「取單筆待辦」的端點
同步刊登於 Blog
圖片來源:AI 產生